Orders Overview
The Patient Portal Orders API lets the authenticated patient list the orders tied to their own cases. The endpoint is self-only: the JWT subject is the only patient whose records are returned, scoped to the cases owned by that patient in the calling organization.
Endpoints
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/v1/users/me/orders | List the patient's orders (optionally filtered by case) |
Related resources: Medications (/me/medications) and Payments (/me/payments).
Authentication
Every endpoint requires a successful /verify-otp exchange first.
| Header | Required | Description |
|---|---|---|
cv-api-key | Yes | Tenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR. |
Authorization | Yes | Bearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401. |
The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".
Permission Matrix
| Action | Allowed when… |
|---|---|
| List own orders | Always (filtered to the patient's cases in the calling org). |
| List a specific case's orders | The case is owned by the patient (submitterId) and belongs to the calling org. Otherwise → 403. |
When caseId is omitted the server resolves the patient's own case ids in the calling organization first; if the patient has no cases, the response data array is [] and nextCursor is null.
Response Envelope
The list is wrapped under orders alongside the pagination cursor:
{
"status": 200,
"success": true,
"data": {
"orders": [ "..." ],
"nextCursor": "<id> | null"
}
}
Error responses follow:
{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }
Query Parameters
| Field | Type | Required | Notes |
|---|---|---|---|
caseId | string (UUID) | No | Restrict the list to a single case owned by the patient. Verified via ensurePatientOwnsCase — if the case does not belong to the patient or to the calling org, returns 403. |
limit | integer | No | 1–100. Defaults to 20. Coerced from string. |
after | string (UUID) | No | Cursor — the last id from the previous page. The server skips that row and returns the next page. |
Pagination
Cursor-based over the row id, ordered by createdAt descending (newest first):
- Request page 1 without
after. The server returns up tolimititems plusnextCursor. - If
nextCursoris non-null, pass it asafter=<nextCursor>to fetch the next page. - When the server has no more rows,
nextCursorisnull.
The cursor is the last item's id. Internally the server takes limit + 1 rows, drops the extra, and emits its id as the cursor — so a null cursor unambiguously means "no more pages."
Object Shapes
Order
Returned by GET /me/orders.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Order.id. |
status | enum | One of DRAFT, PLACED, PROCESSING, HOLD, SHIPPED, OUT_FOR_DELIVERY, DELIVERED, RETURNED, CANCELLED, ERROR, TRANSIT_FAILURE. |
trackingId | string | null | Carrier tracking id, if assigned. |
externalId | string | null | Id from the upstream pharmacy / fulfillment system. |
caseId | string (UUID) | The case the order belongs to. |
createdAt | ISO-8601 datetime | Server-generated. |
medications | Array<{ medicine, dosage, dosingFrequency }> | Flattened from the order's CaseDecision.medInfo. Empty array if the order has no decision or no med info. |
Server-Side Behaviors and Defaults
- Tenant + ownership scoping. With or without
caseId, the result is restricted to cases wheresubmitterId = userIdandorganizationId = req.patientOrganization.id. There is no cross-tenant or cross-patient surface. caseIdis pre-validated. When supplied,ensurePatientOwnsCaseruns before the list query; failure short-circuits to403.- Default
limit.20. Maximum100. The validator coerces string → number. - Cursor semantics.
afteris the last row'sidfrom the previous page; the server usescursor: { id: after }, skip: 1and asks forlimit + 1rows to detect end-of-results. - Empty patient. If the patient has no cases in the calling org (and no
caseIdwas supplied), the endpoint returns{ orders: [], nextCursor: null }— no error.
Security Properties
- Tenant isolation. Case-id resolution and the row-level filter both pin
organizationIdto the calling org fromcv-api-key. - Ownership isolation. Case-id resolution pins
submitterIdto the JWT subject;caseIdqueries additionally pass throughensurePatientOwnsCase. - Uniform 403. "Doesn't exist", "not yours", and "wrong tenant" all collapse to the same
403 VALIDATION_ERROR"You do not have access to this case" so case ids cannot be probed. - Token type pinned. Only JWTs with
type: 'patient-portal'reach the handler. - Cross-tenant defense. The JWT's
organizationIdis verified against thecv-api-key-resolved org on every call. - No write surface. The endpoint is read-only.
Integrator Guidance
- Refresh proactively. Refresh the access token via
/refresh-tokenbefore the 15-minute expiry. - Listing strategy. Use
?caseId=when surfacing orders within a single-case view; omit it for an account-wide list. - Paginate forward only. The cursor moves forward through the sort order — there is no
beforecursor. - Treat shapes as endpoint-specific. A medication embedded inside an
Order(order.medications[]) has onlymedicine / dosage / dosingFrequency. The richer per-row shape (withprescriber,refillCount, etc.) is only available onGET /me/medications. - Empty
medications[]on an order is normal — it just means the underlyingCaseDecisionhad nomedInforows. - Treat
403as "no access, may or may not exist". Do not display case-id-specific debug text.